1"use client";
2
3
13import { useMemo, useState } from "react";
14import { useParams, useRouter } from "next/navigation";
15import { useMutation, useQueryClient } from "@tanstack/react-query";
16import {
17 AtSign,
18 Bell,
19 CheckCheck,
20 Database,
21 Link2,
22 Loader2,
23 MessageSquare,
24 PersonStanding,
25 Puzzle,
26 Workflow
27} from "lucide-react";
28import { cn, formatRelativeTime } from "@/lib/utils";
29import { notificationApi } from "@/lib/api";
30import { useNotifications, useNotificationList } from "@/hooks/use-notifications";
31import { useWorkspace } from "@/hooks/use-workspace";
32import { UserAvatar } from "@/components/database/cell-renderer";
33import type { Notification, NotificationLink } from "@/lib/types";
34
35
36const TYPE_ICON: Record<Notification["type"], React.ComponentType<{ className?: string }>> = {
37 MENTION: AtSign,
38 COMMENT: MessageSquare,
39 ASSIGNMENT: PersonStanding,
40 PAGE_SHARED: Link2,
41 TASK_ASSIGNED: CheckCheck,
42 DATABASE_CHANGE: Database,
43 AUTOMATION: Workflow,
44 INTEGRATION: Puzzle
45};
46
47
53function resolveNotificationHref(
54 link: NotificationLink | null | undefined,
55 workspaceSlug: string
56): string | null {
57 if (!link) return null;
58 switch (link.type) {
59 case "page":
60 return `/w/${workspaceSlug}/page/${link.id}`;
61 case "database":
62 return `/w/${workspaceSlug}/databases/${link.id}`;
63 case "record":
64 case "comment":
65
66
67
68 return link.pageId ? `/w/${workspaceSlug}/page/${link.pageId}` : null;
69 default:
70 return null;
71 }
72}
73
74export default function NotificationsPage() {
75 const params = useParams<{ workspace: string }>();
76 const workspaceSlug = params?.workspace ?? "";
77 const { data: workspace } = useWorkspace(workspaceSlug);
78 const router = useRouter();
79 const qc = useQueryClient();
80 const [unreadOnly, setUnreadOnly] = useState(false);
81
82
83
84 const { data: all = [] } = useNotificationList();
85 const { invalidate, unreadCount } = useNotifications();
86 const wsSlug = workspace?.slug || workspaceSlug;
87
88 const items = useMemo(
89 () => (unreadOnly ? all.filter((n) => n.readAt === null) : all),
90 [all, unreadOnly]
91 );
92 const unread = useMemo(() => items.filter((n) => n.readAt === null), [items]);
93 const read = useMemo(
94 () => items.filter((n) => n.readAt !== null).slice(0, 50),
95 [items]
96 );
97
98 const markRead = useMutation({
99 mutationFn: (id: string) => notificationApi.markRead(id),
100 onSuccess: () => {
101 qc.invalidateQueries({ queryKey: ["notifications", "list"] });
102 invalidate();
103 }
104 });
105 const markAllRead = useMutation({
106 mutationFn: () => notificationApi.markAllRead(),
107 onSuccess: () => {
108 qc.invalidateQueries({ queryKey: ["notifications", "list"] });
109 invalidate();
110 }
111 });
112
113 const openItem = (n: Notification) => {
114 const href = resolveNotificationHref(n.link, wsSlug);
115 if (n.readAt === null) markRead.mutate(n.id);
116 if (href) router.push(href);
117 };
118
119 const renderRow = (n: Notification, dimmed: boolean) => {
120 const Icon = TYPE_ICON[n.type] ?? Bell;
121 const href = resolveNotificationHref(n.link, wsSlug);
122 const unreadRow = n.readAt === null;
123 return (
124 <li
125 key={n.id}
126 data-testid={`notification-row-${n.id}`}
127 className={cn(
128 "flex items-start gap-3 px-3 py-3",
129 unreadRow && !dimmed && "bg-primary/5",
130 dimmed && "opacity-60"
131 )}
132 >
133 <span className="mt-0.5 shrink-0">
134 {n.actor ? (
135 <UserAvatar user={{ id: n.actor.id, name: n.actor.name, avatarUrl: n.actor.avatarUrl }} size={28} />
136 ) : (
137 <span className="flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
138 <Icon className="size-4" aria-hidden />
139 </span>
140 )}
141 </span>
142 <div className="min-w-0 flex-1">
143 <p className={cn("text-sm", unreadRow && !dimmed && "font-medium")}>
144 {n.actor ? (
145 <span className="text-muted-foreground">{n.actor.name} · </span>
146 ) : null}
147 {n.title}
148 </p>
149 {n.body ? (
150 <p className="mt-0.5 truncate text-xs text-muted-foreground">{n.body}</p>
151 ) : null}
152 <p className="mt-0.5 text-[11px] text-muted-foreground">{formatRelativeTime(n.createdAt)}</p>
153 </div>
154 {unreadRow && !dimmed ? (
155 <span className="mt-1 size-2 shrink-0 rounded-full bg-primary" aria-label="Unread" />
156 ) : null}
157 {href ? (
158 <button
159 type="button"
160 className="shrink-0 rounded-md px-2 py-1 text-xs text-primary hover:bg-primary/10"
161 onClick={() => openItem(n)}
162 >
163 Open
164 </button>
165 ) : null}
166 </li>
167 );
168 };
169
170 return (
171 <div className="mx-auto max-w-2xl px-6 py-8">
172 <div className="mb-4 flex items-center justify-between">
173 <h1 className="text-xl font-semibold">Notifications</h1>
174 <div className="flex items-center gap-2">
175 <button
176 type="button"
177 className="rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
178 onClick={() => setUnreadOnly((v) => !v)}
179 aria-pressed={unreadOnly}
180 >
181 {unreadOnly ? "Show all" : "Unread only"}
182 </button>
183 <button
184 type="button"
185 data-testid="mark-all-read"
186 className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
187 onClick={() => markAllRead.mutate()}
188 disabled={markAllRead.isPending || unreadCount === 0}
189 >
190 {markAllRead.isPending ? (
191 <Loader2 className="size-3.5 animate-spin" />
192 ) : (
193 <CheckCheck className="size-3.5" />
194 )}
195 Mark all read
196 </button>
197 </div>
198 </div>
199
200 {all.length === 0 ? (
201 <p className="text-sm text-muted-foreground">You're all caught up.</p>
202 ) : (
203 <div className="space-y-6">
204 {unread.length > 0 && (
205 <section aria-label="Unread notifications">
206 <h2 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
207 Unread ({unread.length})
208 </h2>
209 <ul role="list" className="divide-y divide-border rounded-md border border-border">
210 {unread.map((n) => renderRow(n, false))}
211 </ul>
212 </section>
213 )}
214 {read.length > 0 && (
215 <section aria-label="Read notifications">
216 <h2 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
217 Read
218 </h2>
219 <ul role="list" className="divide-y divide-border rounded-md border border-border">
220 {read.map((n) => renderRow(n, true))}
221 </ul>
222 </section>
223 )}
224 {unreadOnly && unread.length === 0 && all.length > 0 && (
225 <p className="text-sm text-muted-foreground">No unread notifications.</p>
226 )}
227 </div>
228 )}
229 </div>
230 );
231}
232